系列說明 >> 本系列大部分 PR 來自私人或公司專案,部分程式碼、環境設定與實作細節不便公開。
我只能保證文中提到的 PR 與問題皆為真實案例,但會經過必要的匿名化與內容調整。
本系列主要分享問題如何被發現、背後的思考方式,以及解法如何形成,不會深入討論完整實作與部署細節,敬請見諒。
Change Ref: PR #131-feat(audit): P2 gate 補強
Issue: Audit 已經有 verify-edit → record → decide → apply → PR,但各階段仍可能拿到不同版本的 YAML;Source Citation 也沒有被可靠綁定到同一份 Source Tree。
Root Cause: Gate 有檢查「步驟做過沒」,卻沒有把 Before、Proposed、Source Evidence 與最後 Apply 的 Artifact 綁成同一條 Identity Chain。
Solution: verify-edit 的 Before 改由 RID+Case 自動解析;Proposed 固定 Snapshot 並留下 SHA;apply 同時檢查 Proposed SHA 與 Target Drift;Citation 綁定指定 Source Root 的 Git Identity。
Evidence: Audit Tests 214 passed,包含 Before 調包、跳過 verify-edit、Target Drift、空 Citation 等負向測試;Policy Check 無 Fail,另以 Dry Run 驗證 Source-root Git Identity。本 PR 的驗證不需要操作硬體。
上一篇,我們終於在碰 DUT 以前先讀 Workbook。
Not Supported、Skip、To Be Tested 先分掉,不要先 Upgrade FW、占 DUT、占 STA、占 Serial,忙了一輪才發現:
喔,這題不用測。
省下來的時間很多。
剩下真的需要 Audit 的 Case,才讓 Agent 去查 Source、跑 Live Probe、提出 Case YAML 修改。
到這裡流程其實已經滿像回事了:
Official Case
↓
Proposed YAML
↓
verify-edit
↓
record
↓
decide
↓
apply
↓
PR
每一站都有 Gate。
每一站看起來也都找得到人背鍋。
很有制度。
然後問題來了。
Agent 說:
「這份 YAML 我已經驗過了。」
我看了一眼路徑。
「你驗哪一份?」
它指到一份暫存的 Proposed YAML。
「那等等 apply 用哪一份?」
又是另一個位置。
欸。
事情突然開始有點抽象。
明明只是改一份 YAML,聊著聊著已經快變成:
你是誰?
你從哪裡來?
剛才被驗的是你嗎?
等等被 Apply 的還是你嗎?
老Go在旁邊補了一句:
「內容一樣不就好了?」
對。
如果真的一樣。
問題是,當時沒有任何 Gate 能一翻兩瞪眼地證明:最後要 Apply 的,就是剛才驗過的那一份。
我們前面花了一堆力氣防 Agent 亂改正式 Case,最後最重要的保護機制居然是:
大家記得不要換檔案喔。
這不是 Gate。
這比較像拜拜。
原本的 verify-edit 大致長這樣:
testpilot audit verify-edit <RID> <case> \
--yaml <before.yaml> \
--proposed <after.yaml>
設計本身很合理。
before.yaml 是原始 Case,after.yaml 是 Agent 提出的修改版。
Gate 比較兩份內容,只允許動白名單欄位。像 Criteria、Expected 這類 Audit 本來就允許修的地方可以動,Case Identity 或其他禁區亂碰就擋。
很直白。
問題只在一個小地方:
--yaml <before.yaml>
這份 Before 是 Caller 自己指定的。
也就是說,正式 Case 明明在 Repo 裡,我卻可以另外準備一份「號稱是 Before」的 YAML 給 Gate。
例如正式 Case 是:
A B C D E
其中 C 是不能改的。
那我先做一份假的 Before:
A B X D E
再做 Proposed:
A B X D F
Gate 比較的其實是:
假的 Before → Proposed
E → F
白名單內。
PASS。
真正不該動的:
C → X
早就在「我自己帶 Before 進考場」的時候處理掉了。
Gate 沒壞。
Diff 沒壞。
白名單也沒壞。
大家都正常上班。
只是整群人很有秩序地驗錯東西。
老Go說:
「那規定 Agent 一定要傳正式 Case 不就好了?」
當然可以。
README 寫一次。
Agent Guide 再寫一次。
Skill 裡補個大大的:
IMPORTANT!!!
DO NOT USE THE WRONG BEFORE YAML!!!
最好三個驚嘆號,誠意比較夠。
以前我也會把這種東西叫做「一翻兩瞪眼的規則」。
後來發現其實比較接近:
大家有看到的話記得一下。
換成工程語言就是:
根本沒有 Gate。
Audit 已經有 RID,其實根本不用問 Agent 原稿在哪裡。
RID 知道這次 Audit 的工作範圍,Manifest 知道 Cases Directory,Case ID 又已經指定成 D123。
那就自己找。
所以 PR #131 第一刀很乾脆:
把 --yaml 拿掉。
testpilot audit verify-edit <RID> D123 \
--proposed /tmp/D123-after.yaml
Before 改成由系統自己解析:
RID
↓
manifest
↓
cases_dir
↓
D123
↓
Official Case YAML
找不到,Fail。
同一個 Case 找到兩份,Ambiguous,Fail。
只有唯一的正式 Case 可以當 Before。
以前是:
Agent:這是原稿。
Gate:好喔。
現在變成:
Agent:這是我改的。
Gate:原稿我自己找,你顧好 Proposed 就好。
這樣比較合理。
考生可以交答案。
題目原本長什麼樣,不要也讓考生順便包辦。
不然那個 Audit 有點太自由行。
做到這裡,我原本也覺得差不多了。
Before 已經綁正式 Case。
Proposed 跑 Boundary Check。
Schema Check 也過。
留下 Audit Record。
收工。
然後 Adversarial Review 又把下一個洞挖出來。
假設流程是:
10:00:00 verify-edit 讀 proposed.yaml
10:00:01 Boundary Check PASS
10:00:02 Schema Check PASS
10:00:03 另一個 Process 修改 proposed.yaml
10:00:04 系統把 proposed.yaml 存進 Audit Run
10:00:05 寫下「已驗證」
請問 10:00:05 那個「已驗證」,驗的是誰?
前面驗 A。
中途檔案被換成 B。
最後留下來的是 B。
Audit Record 還是一臉正氣:
PASS
這次連 Agent 都不一定要搞事。
Editor Auto-save、另一個 Script、另一個 Agent,甚至我自己手滑,都可能在 Check 和 Use 中間動到檔案。
老問題了。
TOCTOU,Time Of Check To Time Of Use。
最新的 Agent Workflow 裡,住著一隻不知道幾歲的老 Bug。
有一種 AI 發展得很快,但 Bug 祖譜完全沒斷過的安心感。
所以只記:
/tmp/D123-after.yaml
其實不夠。
這只能證明:
去這裡找。
不能證明:
你現在找到的,就是剛才驗過的那一坨 Bytes。
同一個 Filename,五秒前住 A,五秒後住 B,也沒有人犯法。
所以 verify-edit 改成先把 Proposed 固定成一份 Snapshot,再用同一份內容一路做:
read proposed bytes once
↓
Pinned Snapshot
↓
Boundary Check
↓
Schema Check
↓
SHA
↓
Atomic Stage
↓
最後才寫 Verify Record
也就是 Boundary Check、Schema、Hash,最後 Stage 進 Audit Run 的內容,都必須是同一組 Bytes。
Stage 失敗,就不能留一筆「我驗過」在那邊唬爛。
如果正式 Before 在驗證途中又被別人改掉,也停。
因為你剛剛驗的明明是:
Before A → Proposed B
現在世界已經變:
Before A.5 → Proposed B
還硬說「剛才驗過了啦」,這不是自動化,這叫硬拗。
所以我很喜歡這個理解方式:
Filename 是地址,不是身分證。
final_v3_really_final.yaml 名字取得再真誠,都不能證明它現在還是剛才那一份。
工程師看這種檔名應該更有感。
到這裡已經可以確認 Proposed 沒被換掉。
但還差另外一邊。
正式 Case 也會變。
例如早上:
Official V1
↓
verify-edit
↓
Proposed V2
一切都正確。
下午另一個人先把正式 Case 改成 V1.5。
晚上我拿早上的 V2 回來 Apply:
V1
├─ 我的 V2
└─ 別人的 V1.5
最後:V2 蓋回去
Proposed SHA 對不對?
對。
它從早到晚都是 V2,忠貞不二。
問題是 V2 當初驗證的基準是 V1,不是現在的 V1.5。
這份修改已經 Stale 了。
所以 apply 現在要同時看兩邊:
現在 Proposed SHA
==
verify-edit 當時的 After SHA
而且:
現在 Target SHA
==
verify-edit 當時的 Before SHA
兩條都成立,才 Apply。
第一條防你驗 A、偷換 B。
第二條防你拿昨天 A→B 的答案,跑來蓋今天已經變成 A.5 的題目。
老Go看了一眼:
「這不就 Optimistic Lock?」
對。
沒有什麼魔法。
以前拿來擋 Database Concurrent Update,現在拿來擋 Agent 拿舊答案回來蓋新 Case。
技術日新月異。
工程師被同一批問題追著跑的方式也日新月異。
YAML 的身分開始綁住之後,還有 Source Evidence。
Agent 修改 Criteria 時,Audit 本來就要求 Citation,例如 Source File、Line、Snippet,再由 record 驗證。
結果還有一種很漂亮的綠燈:
{
"citations": []
}
最後可能得到:
citations_verified = true
不是程式算錯。
如果邏輯最後落到:
all([])
它本來就是真。
所有零個 Citation,全數驗證通過。
數學很開心。
工程上就有點北七。
所以 PR #131 直接把空 Citation 改成 Hard Fail。
要進 applied,至少得有:
verify-edit record
+
non-empty citations
+
citations_verified == true
沒有 Evidence,可以 Pending,可以 Block。
就是不要兩手空空站在門口,然後跟我說:
「我帶的證據全部都確認過了。」
你確認了個寂寞。
Citation 還有一個很實際的坑。
Case Repo 跟 DUT Firmware Source 往往不是同一棵 Tree。
Citation 如果只寫:
src/wifi/driver.c:123
這個相對路徑到底相對誰?
如果拿 TestPilot/Plugin Repo 去解,當然找不到 Firmware Source。
最省事的方式是全部塞絕對路徑。
今天能跑。
換台機器,變古蹟。
更麻煩的是 Source 更新後,檔名還是 driver.c,行號甚至可能差不多,但內容早就不是當時那一版。
所以這次 audit init 加上 --source-root,並在 Manifest 留下這棵 Source Tree 的 Git Identity:
Source Root
Git SHA
Git Describe
這樣 Citation 說的才不是:
某台機器上有一個同名檔案。
而是:
這次 Audit 綁定的那一版 DUT Source 裡,這個 Citation 存在。
做到這裡,這篇其實已經不像在改 YAML。
比較像一路在查戶口。
Before:你誰?
Proposed:你還是剛才那個誰?
Target:你中途有沒有換人?
Source:你是哪一版的誰?
PR #131 做的那些 SHA、Snapshot、Source-root,看起來東一塊西一塊,其實都在處理同一件事:
不要讓每一個 Gate 都各自 PASS,最後才發現大家講的根本不是同一份東西。
PR #131 最後的 Audit Test:
214 passed
裡面包含幾個我比較在意的負向情境:
Before 被調包
→ verify-edit 拒絕
跳過 verify-edit
→ apply 拒絕
verify-edit 後 Target Drift
→ apply 拒絕
Citation 為空
→ applied 拒絕
Policy Check 沒有 Fail,也用 Dry Run 確認 Source Root 的 Git Identity 會被寫進 Manifest。
這次完全沒去占 DUT。
因為 DUT 可以回答:
Runtime 現在長什麼樣。
它回答不了:
你十分鐘前 Verify 的 YAML,跟你現在 Apply 的 YAML,是不是同一份。
硬把板子拖進來,只是多一塊無辜硬體陪我們看 SHA。
所以 214 passed 也只證明這批已知 Identity Bypass 現在有機器擋。
它不代表 Criteria 語意一定正確,也不代表 Citation 的內容一定真的支持 Agent 的推論。
那幾筆帳後面還會再來。
今天先處理最基本的:
驗 A,就不要最後交 B。
走到這裡,一個 Case 從 Before、Proposed、Source Evidence 到 Apply,總算比較像同一條 Chain。
很好。
老Go問:
「那 415 條可以開始跑了?」
「可以。」
「跑到第 287 條 Terminal 掛掉呢?」
……
「今天下班前只想先處理十條呢?」
……
「昨天已經跑兩百條,今天從 201 繼續?」
我看了一眼當時的 pass12。
好消息。
它很公平。
不管你昨天跑了幾條,今天都可以重新做人。
四百多條 Case。
每一條現在都有身分證、有 Gate、有 Audit Record。
然後整趟最好一次跑完。
嗯。
這個節奏又開始不對了。
我們剛把「你到底驗了誰」這件事管住。
下一個麻煩變成:
這麼長的 Audit,憑什麼不能中途下車?
下一篇,開始做 Worklist、Case Subset,還有真正能用的 Resume。
不然這不是 Audit。
這比較像耐力賽。
Have a nice day.